Skip to content

fix(sandbox): resolve symlinks before path containment check in sandboxed fs.readFile - #13

Merged
jddunn merged 2 commits into
framerslab:masterfrom
andesyteoss:fix/cwe22-sandboxedtoolforge-sandboxed-772c
Oct 1, 2026
Merged

jddunn merged 2 commits into
framerslab:masterfrom
andesyteoss:fix/cwe22-sandboxedtoolforge-sandboxed-772c

Conversation

@andesyteoss

@andesyteoss andesyteoss commented Jun 2, 2026 •

Copy link
Copy Markdown

Summary

The sandboxed fs.readFile implementation in SandboxedToolForge uses path.resolve() to canonicalize file paths before checking whether they fall within the configured fsReadRoots. However, path.resolve() only normalizes lexical components (., ..) — it does not resolve symbolic links. An attacker who can place or leverage an existing symlink inside an allowed root can read arbitrary files on the host filesystem, bypassing the sandbox containment.

This is a path traversal vulnerability (CWE-22) in a security-critical code path: SandboxedToolForge is explicitly designed to run untrusted, AI-generated code in a restricted environment.

Fix

The fix adds realpath() from node:fs/promises to resolve symlinks to their physical path before the startsWith containment check:

Before (vulnerable):

import { readFile } from 'node:fs/promises';
// ...
const resolvedPath = path.resolve(filePath);

After (fixed):

import { readFile, realpath } from 'node:fs/promises';
// ...
const resolvedPath = await realpath(path.resolve(filePath));

This ensures the containment check operates on the true filesystem path. If the symlink target is outside the allowed roots, the read is correctly blocked.

Proof of Concept

Given a deployment where fsReadRoots is set to ["/app/data"]:

# Setup: create a symlink inside the allowed root pointing outside it
ln -s /etc/passwd /app/data/escape

# The sandboxed code calls:
# fs.readFile('/app/data/escape')
#
# Without the fix:
#   path.resolve('/app/data/escape') → '/app/data/escape'
#   '/app/data/escape'.startsWith('/app/data/') → true  ← BYPASSED
#   readFile reads /etc/passwd
#
# With the fix:
#   path.resolve('/app/data/escape') → '/app/data/escape'
#   realpath('/app/data/escape')     → '/etc/passwd'
#   '/etc/passwd'.startsWith('/app/data/') → false  ← BLOCKED

The symlink must pre-exist on the filesystem within an allowed root. In containerized deployments this can occur via volume mounts, prior code execution, or build artifacts.

Checklist

  • Tests added/updated — the fix is a single-line behavioral change; the containment logic and error message remain identical
  • Docs updated (README or typedoc) — no doc changes needed for an internal security fix
  • Conventional commit title/body

Related

N/A — no existing issue for this.

Security Analysis

The SandboxedToolForge class runs agent-generated JavaScript in a sandboxed vm context and selectively exposes fs.readFile as an allowlisted API. The fsReadRoots mechanism is the sole access control preventing reads outside designated directories. By using only path.resolve() (lexical normalization), the original code was vulnerable to symlink-based traversal — a well-known bypass for path containment checks that rely on string prefix matching without resolving the underlying filesystem links.

Before submitting, we verified that no other layer mitigates this: the CodeSandbox executor does not perform its own path checks on fs.readFile calls, and Node.js vm contexts do not restrict filesystem access. The fsReadRoots check in SandboxedToolForge is the only barrier, and the symlink bypass is real.

The precondition — a symlink existing within an allowed root — is plausible in production: container images may ship development symlinks, volume mounts can introduce them, and prior sandbox executions (if any created files) could establish them.

Summary by Sourcery

Bug Fixes:

  • Prevent symlink-based sandbox escapes by resolving requested files to their canonical filesystem paths before enforcing configured read-root containment.

Summary by CodeRabbit

  • Bug Fixes
    • File reads now check the file’s actual location against permitted directories, including when paths resolve through symbolic links. This prevents files outside permitted locations from being read by using a path that appears to be inside one.

…eadFile

Use fs.realpath() to resolve symlinks before checking whether the path
falls within allowed roots, and pass the resolved path to readFile().
This prevents symlink-based path traversal (CWE-22) where a symlink
inside an allowed root points to a file outside it.
@coderabbitai

coderabbitai Bot commented Jun 2, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Repository: framerslab/agentos/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: ad2bf767-eb06-4f66-b502-b486ac681168

📥 Commits

Reviewing files that changed from the base of the PR and between 5932174 and 54d1cce.

📒 Files selected for processing (1)
  • src/cognition/emergent/SandboxedToolForge.ts
 _______________________________________________________________________________________________________________
< Walking on water and developing software from a specification are easy if both are frozen. - Edward V. Berard >
 ---------------------------------------------------------------------------------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Comment @coderabbitai help to get the list of available commands.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Review Summary by Qodo

Resolve symlinks in sandboxed fs.readFile containment check

🐞 Bug fix

Grey Divider

Walkthroughs

Description
• Resolves symlinks before path containment check in sandboxed fs.readFile
• Prevents CWE-22 path traversal via symlink bypass in security-critical sandbox
• Uses fs.realpath() to canonicalize paths before allowlist validation
• Passes resolved path to readFile() to ensure correct file access
Diagram
flowchart LR
  A["filePath input"] --> B["path.resolve()"]
  B --> C["realpath() resolve symlinks"]
  C --> D["Check containment"]
  D --> E{Allowed?}
  E -->|Yes| F["readFile with resolved path"]
  E -->|No| G["Throw error"]

Loading

Grey Divider

File Changes

1. src/cognition/emergent/SandboxedToolForge.ts 🐞 Bug fix +0/-0

Add symlink resolution to fs.readFile security check

• Added realpath import from node:fs/promises to resolve symbolic links
• Modified fs.readFile handler to await realpath() on resolved path before containment check
• Updated readFile call to use the fully resolved path instead of original filePath
• Prevents symlink-based path traversal attacks within allowed root directories

src/cognition/emergent/SandboxedToolForge.ts


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 2, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Realpath before containment 🐞 Bug ⛨ Security
Description
fs.readFile calls realpath() on attacker-controlled paths before verifying lexical containment,
so out-of-root probes can throw ENOENT/EACCES that bubble to the caller and disclose host
filesystem existence/permission info. This is a security regression vs. consistently returning the
sandbox "blocked" error for out-of-root paths.
Code

src/cognition/emergent/SandboxedToolForge.ts[R477-480]

Evidence
The sandboxed fs.readFile resolves the path via realpath() before determining whether it is
allowed, and errors thrown from sandbox execution are returned to the caller as part of the
execution error string, enabling out-of-root probing via differing error messages.

src/cognition/emergent/SandboxedToolForge.ts[474-487]
src/cognition/emergent/SandboxedToolForge.ts[398-410]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`realpath(path.resolve(filePath))` is executed before any allowlist/root containment decision. If the requested path is outside `fsReadRoots`, `realpath()` can throw `ENOENT`/`EACCES` and the error is surfaced back to the sandbox caller, leaking host filesystem details (existence/permissions) that the sandbox is intended to hide.
### Issue Context
You need symlink resolution to prevent CWE-22, but the implementation should avoid touching the filesystem for paths that are clearly outside the allowed roots.
### Fix Focus Areas
- src/cognition/emergent/SandboxedToolForge.ts[474-493]
### Suggested approach
1. Compute `const candidate = path.resolve(filePath)`.
2. Perform a **lexical** containment check against `this.fsReadRoots` using `candidate`.
- If not lexically within any root, throw the existing `fs.readFile blocked: ... outside the allowed roots` error **without calling `realpath()`**.
3. Only after lexical acceptance, compute `const resolvedPath = await realpath(candidate)`.
4. Perform a **physical** containment check (see separate finding about canonicalizing roots) and then `readFile(resolvedPath)`.
5. Optionally normalize/catch any `realpath` failures for lexically-allowed paths to a consistent sandbox error format if you do not want to leak details even within roots.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Roots not realpathed 🐞 Bug ≡ Correctness
Description
The candidate path is now canonicalized with realpath(), but fsReadRoots are still only
path.resolve()'d, so containment can incorrectly fail when an allowed root includes symlink
components (real path won’t start with the lexical root). This can block legitimate reads for
deployments that configure fsReadRoots using symlinked directories.
Code

src/cognition/emergent/SandboxedToolForge.ts[R477-480]

Evidence
fsReadRoots are computed with path.resolve() in the constructor, while fs.readFile now uses
realpath() for the candidate path; comparing these two different canonical forms breaks prefix
checks when roots contain symlinks.

src/cognition/emergent/SandboxedToolForge.ts[172-178]
src/cognition/emergent/SandboxedToolForge.ts[474-480]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`resolvedPath` is a physical path (via `realpath()`), but `this.fsReadRoots` are stored as lexical `path.resolve()` results. If a configured root contains symlinks, the physical path prefix check can fail and deny access even though the file is under the configured root directory.
### Issue Context
This arises due to mixing lexical canonicalization (roots) with physical canonicalization (candidate). For correctness, both sides of the containment check should use the same canonicalization strategy.
### Fix Focus Areas
- src/cognition/emergent/SandboxedToolForge.ts[172-178]
- src/cognition/emergent/SandboxedToolForge.ts[474-493]
### Suggested approach
- Canonicalize `fsReadRoots` using `realpath()` as well.
- Since the constructor cannot `await`, consider:
 - Adding a private cached promise (e.g., `private fsReadRootsReal: Promise<string[]>`) initialized lazily on first `fs.readFile` call, or
 - Switching root canonicalization to a sync call (e.g., `fs.realpathSync`) if acceptable.
- Use the canonicalized root list for the physical containment check against `resolvedPath`.
- Keep lexical check (if you implement it per Finding 1) using the lexical roots, then do the physical check using realpathed roots.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can keep summaries lean with Findings visible per group, which tucks the rest behind a View link

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

const resolvedPath = await realpath(path.resolve(filePath));
const allowed = this.fsReadRoots.some((root) => {
return resolvedPath === root || resolvedPath.startsWith(`${root}${path.sep}`);

P2 Badge Canonicalize allowed roots before comparing real paths

When fsReadRoots is configured with a symlinked directory (for example a deployment path like /app/current pointing at a release directory), resolvedPath now becomes the target's real path while this.fsReadRoots remains only path.resolved in the constructor. Legitimate reads under that configured root are therefore rejected because /releases/123/file no longer equals or starts with /app/current/; the roots need to be realpathed as well, or the containment check compares different path forms.

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

jddunn added a commit that referenced this pull request Oct 1, 2026
The sandboxed fs.readFile resolved the requested path with realpath before
the containment check (#13). A path outside the read roots still reached the
filesystem, so its ENOENT or EACCES showed whether a host path exists and is
readable, and the blocked message printed where a host symlink points. The
roots were compared unresolved, so a root that is itself a link (macOS /tmp
links to /private/tmp) denied every read beneath it.

The lexical check now runs first. A path that passes is resolved and checked
again against the roots' own real paths, and the resolved path is what gets
read.
@jddunn
jddunn merged commit 54d1cce into framerslab:master Oct 1, 2026
1 of 2 checks passed
@jddunn

jddunn commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Thank you @andesyteoss. This is a real issue. A symlink inside an allowed root let sandboxed code read files outside the roots, and your change fixes it by resolving the path with realpath before the containment check and reading the resolved path.

Merged and released in 0.10.26, with credit in the release notes. Appreciate the patience. GitHub showed the file as binary on this PR and we missed it.

#49 is built on your commit. It checks the path against the roots before calling realpath, resolves the roots too, and adds tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants